--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 67d5cdfe620830fbfa518fc123c40af3a4714264
Parents : 59c3a0c
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-20T16:00:05-05:00
feat: security hardening
Changes
30 files changed, 614 insertions(+), 126 deletions(-)
Diff
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 49b08966..26b5ce74 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index ec79217f..eae52a40 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -7900,20 +7900,24 @@ class ReticulumMeshChat:
{"type": "error", "message": "Web audio is disabled in config"},
),
)
- else:
- await self.web_audio_bridge.send_status(websocket_response)
- attached = self.web_audio_bridge.attach_client(websocket_response)
- if not attached:
- await websocket_response.send_str(
- json.dumps(
- {"type": "error", "message": "No active call to attach"},
- ),
- )
+ await websocket_response.close()
+ return websocket_response
+
+ await self.web_audio_bridge.send_status(websocket_response)
+ attached = self.web_audio_bridge.attach_client(websocket_response)
+ if not attached:
+ await websocket_response.send_str(
+ json.dumps(
+ {"type": "error", "message": "No active call to attach"},
+ ),
+ )
async for msg in websocket_response:
message = cast("WSMessage", msg)
if message.type == WSMsgType.BINARY:
- self.web_audio_bridge.push_client_frame(message.data)
+ # Only accept PCM after a successful attach for this socket.
+ if websocket_response in self.web_audio_bridge.clients:
+ self.web_audio_bridge.push_client_frame(message.data)
elif message.type == WSMsgType.TEXT:
try:
data = json.loads(message.data)
diff --git a/meshchatx/src/backend/bot_handler.py b/meshchatx/src/backend/bot_handler.py
index 6358ea23..2d7ad776 100644
--- a/meshchatx/src/backend/bot_handler.py
+++ b/meshchatx/src/backend/bot_handler.py
@@ -305,6 +305,11 @@ class BotHandler:
bot_id = bot_id or uuid.uuid4().hex
bot_storage_dir = storage_dir or os.path.join(self.bots_dir, bot_id)
bot_storage_dir = os.path.abspath(bot_storage_dir)
+ jailed = self._jailed_bot_storage_dir(bot_storage_dir)
+ if not jailed:
+ msg = "Bot storage directory must be under the identity bots directory"
+ raise ValueError(msg)
+ bot_storage_dir = jailed
entry = {
"id": bot_id,
"template_id": template_id,
@@ -317,7 +322,12 @@ class BotHandler:
}
self.bots_state.append(entry)
else:
- bot_storage_dir = entry["storage_dir"]
+ jailed = self._jailed_bot_storage_dir(entry.get("storage_dir"))
+ if not jailed:
+ msg = "Bot storage directory must be under the identity bots directory"
+ raise ValueError(msg)
+ bot_storage_dir = jailed
+ entry["storage_dir"] = bot_storage_dir
entry["template_id"] = template_id
entry["name"] = name or entry.get("name") or f"{template_id.title()} Bot"
if not entry.get("bot_config_dir"):
@@ -513,6 +523,16 @@ class BotHandler:
raise RuntimeError("failed to write announce request") from exc
return True
+ def _jailed_bot_storage_dir(self, storage_dir):
+ """Return realpath only when storage_dir is under this identity's bots dir."""
+ if not storage_dir:
+ return None
+ bots_root = os.path.realpath(self.bots_dir)
+ real = os.path.realpath(storage_dir)
+ if real != bots_root and not real.startswith(bots_root + os.sep):
+ return None
+ return real
+
def delete_bot(self, bot_id):
# Stop it first
self.stop_bot(bot_id)
@@ -526,8 +546,8 @@ class BotHandler:
break
if entry:
- # Delete storage dir
- storage_dir = entry.get("storage_dir")
+ # Delete storage dir only when jailed under bots/
+ storage_dir = self._jailed_bot_storage_dir(entry.get("storage_dir"))
if storage_dir and os.path.exists(storage_dir):
try:
shutil.rmtree(storage_dir)
diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index 5a699cac..d3684a33 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -5,7 +5,9 @@ import logging
import os
import re
import shutil
+import tempfile
import zipfile
+from contextlib import suppress
from datetime import UTC, datetime
from .access_attempts import AccessAttemptsDAO
@@ -485,12 +487,19 @@ class Database:
identity_dir = self._identity_storage_dir()
excluded = {os.path.abspath(path) for path in (exclude_paths or ())}
included: list[str] = []
- for root, dirs, files in os.walk(identity_dir):
- dirs[:] = [d for d in dirs if d not in BACKUP_SKIP_DIR_NAMES]
+ for root, dirs, files in os.walk(identity_dir, followlinks=False):
+ dirs[:] = [
+ d
+ for d in dirs
+ if d not in BACKUP_SKIP_DIR_NAMES
+ and not os.path.islink(os.path.join(root, d))
+ ]
for name in files:
if name in db_basenames or name == BACKUP_MANIFEST_NAME:
continue
full_path = os.path.join(root, name)
+ if os.path.islink(full_path):
+ continue
if os.path.abspath(full_path) in excluded:
continue
rel_path = os.path.relpath(full_path, identity_dir).replace("\\", "/")
@@ -714,44 +723,134 @@ class Database:
paths = self._database_paths()
self._checkpoint_and_close()
- for p in paths.values():
- if os.path.exists(p):
- os.remove(p)
-
- if zipfile.is_zipfile(backup_path):
- target_dir = os.path.dirname(paths["main"])
- with zipfile.ZipFile(backup_path, "r") as zf:
- for member in zf.namelist():
- if member.endswith("/"):
- continue
- self._safe_zip_extract_member(zf, member, target_dir)
- else:
- shutil.copy2(backup_path, paths["main"])
-
- if not self._looks_like_sqlite(paths["main"]):
- raise DatabaseRestoreError("Restored file is not a valid SQLite database")
-
+ target_dir = os.path.dirname(paths["main"])
+ main_name = os.path.basename(paths["main"])
+ staging_dir = tempfile.mkdtemp(prefix=".meshchatx-restore-", dir=target_dir)
+ integrity_rows = None
try:
- self.initialize()
- except Exception as exc:
- raise DatabaseRestoreError(
- f"Restored files from backup but database failed to open: {exc!s}",
- ) from exc
- self._tune_sqlite_pragmas()
- integrity_rows = self.provider.integrity_check()
- integrity = []
- for row in integrity_rows or []:
- if isinstance(row, dict):
- integrity.append(next(iter(row.values())))
+ if zipfile.is_zipfile(backup_path):
+ with zipfile.ZipFile(backup_path, "r") as zf:
+ for member in zf.namelist():
+ if member.endswith("/"):
+ continue
+ self._safe_zip_extract_member(zf, member, staging_dir)
else:
- integrity.append(row[0])
- if integrity and integrity[0] != "ok":
- raise DatabaseRestoreError(
- f"Restored backup failed integrity check: {integrity[0]!s}",
+ shutil.copy2(backup_path, os.path.join(staging_dir, main_name))
+
+ staging_main = os.path.join(staging_dir, main_name)
+ if not os.path.exists(staging_main):
+ # Zip may use a different basename for the primary db file.
+ candidates = [
+ os.path.join(staging_dir, name)
+ for name in os.listdir(staging_dir)
+ if name.endswith(".db") and not name.endswith(("-wal", "-shm"))
+ ]
+ if len(candidates) == 1:
+ staging_main = candidates[0]
+ else:
+ raise DatabaseRestoreError(
+ "Backup does not contain a primary database file",
+ )
+
+ if not self._looks_like_sqlite(staging_main):
+ raise DatabaseRestoreError(
+ "Restored file is not a valid SQLite database",
+ )
+
+ aside_dir = tempfile.mkdtemp(
+ prefix=".meshchatx-aside-",
+ dir=target_dir,
)
+ try:
+ for key, live_path in paths.items():
+ if os.path.exists(live_path):
+ shutil.move(
+ live_path,
+ os.path.join(aside_dir, os.path.basename(live_path)),
+ )
+
+ shutil.move(staging_main, paths["main"])
+ for suffix, key in (("-wal", "wal"), ("-shm", "shm")):
+ staged = os.path.join(
+ staging_dir,
+ os.path.basename(paths["main"]) + suffix,
+ )
+ if not os.path.exists(staged):
+ staged = os.path.join(
+ staging_dir,
+ os.path.basename(staging_main) + suffix,
+ )
+ if os.path.exists(staged):
+ shutil.move(staged, paths[key])
+
+ # Copy any remaining identity-storage files from the zip staging tree.
+ for root, _dirs, files in os.walk(staging_dir, followlinks=False):
+ for name in files:
+ src = os.path.join(root, name)
+ rel = os.path.relpath(src, staging_dir)
+ if rel in {main_name, f"{main_name}-wal", f"{main_name}-shm"}:
+ continue
+ if name.endswith(("-wal", "-shm")) and name.startswith(
+ os.path.splitext(main_name)[0],
+ ):
+ continue
+ dest = os.path.join(target_dir, rel)
+ dest_dir = os.path.dirname(dest)
+ if dest_dir:
+ os.makedirs(dest_dir, exist_ok=True)
+ if os.path.islink(src):
+ continue
+ shutil.copy2(src, dest)
+
+ try:
+ self.initialize()
+ except Exception as exc:
+ self._restore_aside_files(aside_dir, paths)
+ with suppress(Exception):
+ self.initialize()
+ raise DatabaseRestoreError(
+ f"Restored files from backup but database failed to open: {exc!s}",
+ ) from exc
+ self._tune_sqlite_pragmas()
+ integrity_rows = self.provider.integrity_check()
+ integrity = []
+ for row in integrity_rows or []:
+ if isinstance(row, dict):
+ integrity.append(next(iter(row.values())))
+ else:
+ integrity.append(row[0])
+ if integrity and integrity[0] != "ok":
+ self.close_all()
+ self._restore_aside_files(aside_dir, paths)
+ with suppress(Exception):
+ self.initialize()
+ raise DatabaseRestoreError(
+ f"Restored backup failed integrity check: {integrity[0]!s}",
+ )
+ finally:
+ shutil.rmtree(aside_dir, ignore_errors=True)
+ finally:
+ shutil.rmtree(staging_dir, ignore_errors=True)
return {
"restored_from": backup_path,
"integrity_check": integrity_rows,
"health": self.get_database_health_snapshot(),
}
+
+ @staticmethod
+ def _restore_aside_files(aside_dir: str, paths: dict) -> None:
+ """Put previously moved live DB files back after a failed restore."""
+ for live_path in paths.values():
+ aside = os.path.join(aside_dir, os.path.basename(live_path))
+ if not os.path.exists(aside):
+ continue
+ if os.path.exists(live_path):
+ try:
+ os.remove(live_path)
+ except OSError:
+ pass
+ try:
+ shutil.move(aside, live_path)
+ except OSError:
+ pass
diff --git a/meshchatx/src/backend/rncp_handler.py b/meshchatx/src/backend/rncp_handler.py
index 6212d1f3..091bbce1 100644
--- a/meshchatx/src/backend/rncp_handler.py
+++ b/meshchatx/src/backend/rncp_handler.py
@@ -41,6 +41,29 @@ class RNCPHandler:
os.makedirs(path, exist_ok=True)
return path
+ @staticmethod
+ def _safe_received_filename(name) -> str:
+ """Basename-only filename that cannot escape the receive directory."""
+ try:
+ if isinstance(name, (bytes, bytearray)):
+ name = name.decode("utf-8", errors="replace")
+ raw = str(name).replace("\\", "/")
+ base = os.path.basename(raw)
+ except (AttributeError, TypeError, ValueError):
+ return "downloaded_file"
+ if not base or base in {".", ".."} or "\x00" in base:
+ return "downloaded_file"
+ return base
+
+ def _path_under_dir(self, directory: str, filename: str) -> str:
+ """Join and require the final real path stays under directory."""
+ directory = os.path.realpath(directory)
+ candidate = os.path.realpath(os.path.join(directory, filename))
+ if candidate != directory and not candidate.startswith(directory + os.sep):
+ msg = "Refusing path escape outside receive directory"
+ raise PermissionError(msg)
+ return candidate
+
def cancel_transfer(self, transfer_id: str | None = None) -> dict:
"""Mark one or all active transfers as cancelled."""
if transfer_id:
@@ -185,13 +208,13 @@ class RNCPHandler:
if resource.status == RNS.Resource.COMPLETE:
if resource.metadata:
try:
- filename = os.path.basename(
- resource.metadata["name"].decode("utf-8"),
+ filename = self._safe_received_filename(
+ resource.metadata["name"],
)
save_dir = os.path.join(self.storage_dir, "rncp_received")
os.makedirs(save_dir, exist_ok=True)
- saved_filename = os.path.join(save_dir, filename)
+ saved_filename = self._path_under_dir(save_dir, filename)
counter = 0
if self.allow_overwrite_on_receive:
@@ -206,7 +229,7 @@ class RNCPHandler:
while os.path.isfile(saved_filename):
counter += 1
base, ext = os.path.splitext(filename)
- saved_filename = os.path.join(
+ saved_filename = self._path_under_dir(
save_dir,
f"{base}.{counter}{ext}",
)
@@ -304,6 +327,30 @@ class RNCPHandler:
return None
+ def _resolve_fetch_save_dir(self, save_path: str) -> str:
+ """Jail fetch downloads under identity storage (or default downloads dir)."""
+ if (
+ not isinstance(save_path, str)
+ or not save_path.strip()
+ or "\x00" in save_path
+ ):
+ msg = "Invalid save path"
+ raise ValueError(msg)
+ expanded = os.path.expanduser(save_path.strip())
+ if not os.path.isabs(expanded):
+ expanded = os.path.join(self.storage_dir, expanded)
+ real = os.path.realpath(expanded)
+ root = os.path.realpath(self.storage_dir)
+ if real != root and not real.startswith(root + os.sep):
+ msg = "Save path is outside the RNCP download jail"
+ raise PermissionError(msg)
+ parts = {part for part in real.split(os.sep) if part}
+ if parts & {".ssh", ".gnupg"}:
+ msg = "Refusing to save into credential directories"
+ raise PermissionError(msg)
+ os.makedirs(real, exist_ok=True)
+ return real
+
def _resolve_send_path(self, file_path: str) -> str:
"""Resolve a local send path under storage or home, never identity keys."""
if not isinstance(file_path, str) or not file_path or "\x00" in file_path:
@@ -522,11 +569,14 @@ class RNCPHandler:
saved_filename = None
save_error = None
- effective_save_path = (
- os.path.abspath(os.path.expanduser(save_path))
- if isinstance(save_path, str) and save_path.strip()
- else self._default_fetch_save_dir()
- )
+ if isinstance(save_path, str) and save_path.strip():
+ try:
+ effective_save_path = self._resolve_fetch_save_dir(save_path)
+ except (OSError, PermissionError, ValueError) as exc:
+ link.teardown()
+ raise PermissionError(str(exc)) from exc
+ else:
+ effective_save_path = self._default_fetch_save_dir()
def fetch_resource_concluded(resource):
nonlocal resource_resolved, resource_status, saved_filename, save_error
@@ -534,12 +584,12 @@ class RNCPHandler:
if resource.status == RNS.Resource.COMPLETE:
if resource.metadata:
try:
- filename = os.path.basename(
- resource.metadata["name"].decode("utf-8"),
+ filename = self._safe_received_filename(
+ resource.metadata["name"],
)
save_dir = effective_save_path
os.makedirs(save_dir, exist_ok=True)
- saved_filename = os.path.join(save_dir, filename)
+ saved_filename = self._path_under_dir(save_dir, filename)
counter = 0
if allow_overwrite:
@@ -552,7 +602,7 @@ class RNCPHandler:
while os.path.isfile(saved_filename):
counter += 1
base, ext = os.path.splitext(filename)
- saved_filename = os.path.join(
+ saved_filename = self._path_under_dir(
save_dir,
f"{base}.{counter}{ext}",
)
diff --git a/meshchatx/src/backend/rns_filesync_handler.py b/meshchatx/src/backend/rns_filesync_handler.py
index e5d19793..401d7604 100644
--- a/meshchatx/src/backend/rns_filesync_handler.py
+++ b/meshchatx/src/backend/rns_filesync_handler.py
@@ -34,6 +34,27 @@ def _normalize_peer_hash(value: str | None) -> str | None:
return cleaned
+_RESERVED_SYNC_TOP = frozenset(
+ {
+ "identity",
+ "identity.bak",
+ "bots",
+ "plugins",
+ "lxmf",
+ "lxmf_router",
+ "telephone",
+ "rrc_history",
+ "rrc_server",
+ "rncp_received",
+ "rncp_shared",
+ "rncp",
+ "database-backups",
+ "snapshots",
+ "database.db",
+ },
+)
+
+
class RnsFilesyncHandler:
"""Host FileSync against the shared Reticulum stack for one identity."""
@@ -59,6 +80,8 @@ class RnsFilesyncHandler:
self._monitor = True
self._announce_interval = ANNOUNCE_INTERVAL_DEFAULT
self._load_settings()
+ os.makedirs(self._root, exist_ok=True)
+ os.makedirs(self._sync_directory, exist_ok=True)
def _emit(self, event_type: str, payload: dict[str, Any] | None = None) -> None:
if not self._emit_callback:
@@ -77,7 +100,14 @@ class RnsFilesyncHandler:
return None
resolved = os.path.realpath(os.path.expanduser(cleaned))
root = self.storage_dir
- if resolved != root and not resolved.startswith(root + os.sep):
+ # Never sync the whole identity tree (would expose keys / DB).
+ if resolved == root:
+ return None
+ if not resolved.startswith(root + os.sep):
+ return None
+ rel = os.path.relpath(resolved, root)
+ first = rel.split(os.sep, 1)[0]
+ if first in _RESERVED_SYNC_TOP or first.endswith(".db"):
return None
return resolved
@@ -234,7 +264,22 @@ class RnsFilesyncHandler:
target = self._root
if not os.path.isdir(target):
- return {"ok": False, "error": "path is not a directory"}
+ # Default sync path can appear in status before the folder exists.
+ # Walk up to the nearest existing directory still inside the jail.
+ cursor = target
+ while True:
+ parent = os.path.dirname(cursor)
+ if parent == cursor:
+ break
+ if parent != root and not parent.startswith(root + os.sep):
+ break
+ cursor = parent
+ if os.path.isdir(cursor):
+ target = cursor
+ break
+ if not os.path.isdir(target):
+ os.makedirs(self._root, exist_ok=True)
+ target = self._root
entries: list[dict[str, str]] = []
try:
diff --git a/meshchatx/src/backend/rrc/manager.py b/meshchatx/src/backend/rrc/manager.py
index bcf4982f..0a660e02 100644
--- a/meshchatx/src/backend/rrc/manager.py
+++ b/meshchatx/src/backend/rrc/manager.py
@@ -621,11 +621,7 @@ class RRCHub:
def _redact_command_for_history(text):
"""Omit +k secrets from locally recorded command history."""
parts = text.split()
- if (
- len(parts) >= 4
- and parts[0].lower() == "/mode"
- and parts[2].lower() == "+k"
- ):
+ if len(parts) >= 4 and parts[0].lower() == "/mode" and parts[2].lower() == "+k":
return " ".join([*parts[:3], "***"])
return text
diff --git a/meshchatx/src/backend/web_audio_bridge.py b/meshchatx/src/backend/web_audio_bridge.py
index 1e80ad9c..914c215e 100644
--- a/meshchatx/src/backend/web_audio_bridge.py
+++ b/meshchatx/src/backend/web_audio_bridge.py
@@ -340,22 +340,17 @@ class WebAudioBridge:
def push_client_frame(self, pcm_bytes: bytes):
with self.lock:
+ tele = self._tele()
+ if not tele or not getattr(tele, "active_call", None):
+ return
+ if getattr(self.telephone_manager, "is_voicemail_session_active", False):
+ return
if not self.tx_source:
# Hostless LineSource stand-in still accepts PCM until swap.
- tele = self._tele()
- audio_in = getattr(tele, "audio_input", None) if tele else None
+ audio_in = getattr(tele, "audio_input", None)
if audio_in is not None and hasattr(audio_in, "push_pcm"):
- if getattr(
- self.telephone_manager,
- "is_voicemail_session_active",
- False,
- ):
- return
audio_in.push_pcm(pcm_bytes)
return
- # Drop frames during voicemail
- if getattr(self.telephone_manager, "is_voicemail_session_active", False):
- return
self.tx_source.push_pcm(pcm_bytes)
async def _send_bytes_to_all(self, pcm_bytes: bytes):
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index c0edf635..2b9ed26b 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1356,10 +1356,12 @@ export default {
GlobalState.unreadConversationsCount = 0;
GlobalState.missedCallsCount = 0;
+ GlobalState.blockedDestinations = [];
await this.getConfig();
await this.updateRingtonePlayer();
await this.getAppInfo();
+ await this.getBlockedDestinations();
this.updateTelephoneStatus();
this.isSwitchingIdentity = false;
diff --git a/meshchatx/src/frontend/components/filesync/FilesyncDirectoryBrowserModal.vue b/meshchatx/src/frontend/components/filesync/FilesyncDirectoryBrowserModal.vue
index 725cc95b..94bf461b 100644
--- a/meshchatx/src/frontend/components/filesync/FilesyncDirectoryBrowserModal.vue
+++ b/meshchatx/src/frontend/components/filesync/FilesyncDirectoryBrowserModal.vue
@@ -143,7 +143,10 @@ export default {
}
this.newFolderName = "";
const start = String(this.initialPath || "").trim();
- await this.load(start || undefined);
+ const loaded = await this.load(start || undefined);
+ if (!loaded && start) {
+ await this.load(undefined);
+ }
},
},
},
@@ -160,8 +163,10 @@ export default {
this.current = data.current || "";
this.parent = data.parent || null;
this.directories = Array.isArray(data.directories) ? data.directories : [];
+ return true;
} catch (err) {
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
+ return false;
} finally {
this.busy = false;
}
diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
index 1b5b0dc2..49bad81d 100644
--- a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
@@ -707,6 +707,7 @@
'font-size': cv.bubbleMessageBodyFontSizePx(cv.bubbleViewModel(chatItem)) + 'px',
}"
@click="cv.handleMessageClick"
+ @auxclick="cv.handleMessageClick"
v-html="cv.renderMarkdown(cv.bubbleViewModel(chatItem).textForRender)"
></div>
<div
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 95276de8..949b54a5 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -557,6 +557,7 @@ export default {
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
GlobalEmitter.off("refresh-conversations", this.requestConversationsRefresh);
GlobalEmitter.off("websocket-reconnected", this.requestConversationsRefresh);
+ GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
},
mounted() {
this.setupPaneViewportWatchers();
@@ -564,7 +565,9 @@ export default {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
GlobalEmitter.on("compose-new-message", this.onComposeNewMessage);
+ GlobalEmitter.on("refresh-conversations", this.requestConversationsRefresh);
GlobalEmitter.on("websocket-reconnected", this.requestConversationsRefresh);
+ GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
this.getConfig();
this.getConversations();
@@ -598,6 +601,25 @@ export default {
syncUnreadCount() {
GlobalState.unreadConversationsCount = countUnreadConversations(this.conversations);
},
+ onIdentitySwitched() {
+ this.conversationsAbortController?.abort();
+ this.conversations = [];
+ this.conversationListSignature = "";
+ this.hasMoreConversations = true;
+ this.hasLoadedConversations = false;
+ this.lxmfDeliveryAnnounces = [];
+ this.announcesLoaded = false;
+ this.folders = [];
+ this.selectedFolderId = null;
+ for (const pane of this.panes || []) {
+ if (pane && typeof pane === "object") {
+ pane.peer = null;
+ }
+ }
+ this.getConversations();
+ this.getFolders();
+ this.loadConversationPins();
+ },
onAnnouncesTabActivated() {
if (this.announcesLoaded) {
return;
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index e05dccaf..2910acc8 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -600,7 +600,11 @@
import MicronParser from "../../js/MicronParser";
import LinkUtils from "../../js/LinkUtils";
import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
-import { renderNomadPageByPath, resolveNomadPageShellBackground, isolateNomadLinksInHtml } from "../../js/NomadPageRenderer";
+import {
+ renderNomadPageByPath,
+ resolveNomadPageShellBackground,
+ isolateNomadLinksInHtml,
+} from "../../js/NomadPageRenderer";
import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
@@ -1025,10 +1029,12 @@ export default {
this.teardownMultilineExpansion();
WebSocketConnection.off("message", this.onWebsocketMessage);
+ GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
+ GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
this.$watch(
() => GlobalState.config?.nomad_micron_wasm_enabled,
@@ -1098,6 +1104,16 @@ export default {
this.$nextTick(() => this.scheduleProcessPartials());
},
methods: {
+ onIdentitySwitched() {
+ this.favourites = [];
+ this.nodePageCache = {};
+ this.nodes = {};
+ this.selectedNode = null;
+ this.nodePageContent = null;
+ this.clearPartials?.();
+ this.getFavourites();
+ this.getNomadnetworkNodeAnnounces();
+ },
getEmbeddedTabStateHash() {
return (this.selectedNode?.destination_hash || "").trim();
},
@@ -2431,8 +2447,7 @@ export default {
this.isArchiveDropdownOpen = false;
// use parsed destination hash, or fallback to selected node destination hash
- const destinationHash =
- parsedUrl.destination_hash || this.selectedNode?.destination_hash || null;
+ const destinationHash = parsedUrl.destination_hash || this.selectedNode?.destination_hash || null;
if (!destinationHash) {
ToastUtils.warning(this.$t("nomadnet.select_node_to_browse"));
return;
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
index 280e79c7..579ae630 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
@@ -717,6 +717,7 @@ import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import { isUnknownNodeDisplayName } from "../../js/nomadUnknownNodeName.js";
import {
+ clearLocalNomadFavouritesLayout,
loadNomadFavouritesLayout,
readLocalNomadFavouritesLayout,
saveNomadFavouritesLayout,
@@ -963,6 +964,12 @@ export default {
this.reloadFavouriteLayoutFromStore();
};
GlobalEmitter.on("nomadnet-favourites-layout-imported", this._onNomadnetFavouritesLayoutImported);
+ this._onIdentitySwitched = () => {
+ clearLocalNomadFavouritesLayout();
+ this.resetDefaultSections();
+ this.reloadFavouriteLayoutFromStore();
+ };
+ GlobalEmitter.on("identity-switched", this._onIdentitySwitched);
},
unmounted() {
if (this._layoutPersistTimer) {
@@ -976,6 +983,9 @@ export default {
if (this._onNomadnetFavouritesLayoutImported) {
GlobalEmitter.off("nomadnet-favourites-layout-imported", this._onNomadnetFavouritesLayoutImported);
}
+ if (this._onIdentitySwitched) {
+ GlobalEmitter.off("identity-switched", this._onIdentitySwitched);
+ }
},
methods: {
applyFavouriteLayout(layout) {
diff --git a/meshchatx/src/frontend/js/DownloadUtils.js b/meshchatx/src/frontend/js/DownloadUtils.js
index d7a80266..1f28394e 100644
--- a/meshchatx/src/frontend/js/DownloadUtils.js
+++ b/meshchatx/src/frontend/js/DownloadUtils.js
@@ -43,11 +43,28 @@ class DownloadUtils {
return defaultFilename;
}
+ static headerValue(headers, name) {
+ if (!headers) {
+ return undefined;
+ }
+ if (typeof headers.get === "function") {
+ const fromGet = headers.get(name);
+ return fromGet == null || fromGet === "" ? undefined : fromGet;
+ }
+ const lower = String(name).toLowerCase();
+ for (const key of Object.keys(headers)) {
+ if (String(key).toLowerCase() === lower) {
+ return headers[key];
+ }
+ }
+ return undefined;
+ }
+
static async downloadFromApiResponse(response, defaultFilename) {
const headers = response?.headers || {};
- const cd = headers["content-disposition"] || headers["Content-Disposition"];
+ const cd = DownloadUtils.headerValue(headers, "content-disposition");
const filename = DownloadUtils.parseFilenameFromContentDisposition(cd, defaultFilename);
- const type = headers["content-type"] || headers["Content-Type"] || "application/octet-stream";
+ const type = DownloadUtils.headerValue(headers, "content-type") || "application/octet-stream";
const blob = new Blob([response.data], { type });
await DownloadUtils.downloadFile(filename, blob);
}
diff --git a/meshchatx/src/frontend/js/LinkUtils.js b/meshchatx/src/frontend/js/LinkUtils.js
index a444e194..d566508b 100644
--- a/meshchatx/src/frontend/js/LinkUtils.js
+++ b/meshchatx/src/frontend/js/LinkUtils.js
@@ -138,7 +138,9 @@ export default class LinkUtils {
return match;
}
const label = Utils.escapeHtml(core);
- return `${prefix}<a href="${href}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">${label}</a>${suffix}`;
+ // Keep real URL off href so middle-click / target=_blank cannot bypass
+ // in-app stranger-link confirmation handlers.
+ return `${prefix}<a href="#" data-http-url="${Utils.escapeHtml(href)}" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">${label}</a>${suffix}`;
});
}
diff --git a/meshchatx/src/frontend/js/NomadRichHtmlLinks.js b/meshchatx/src/frontend/js/NomadRichHtmlLinks.js
index 3729a52a..6cb593ce 100644
--- a/meshchatx/src/frontend/js/NomadRichHtmlLinks.js
+++ b/meshchatx/src/frontend/js/NomadRichHtmlLinks.js
@@ -67,10 +67,15 @@ export function handleRichHtmlLinkClick(event, options = {}) {
return true;
}
- const externalAnchor = event.target.closest("a[href]");
+ const externalAnchor = event.target.closest("a[href], a[data-http-url]");
if (externalAnchor && !externalAnchor.classList.contains("nomadnet-link")) {
+ const dataHttp = externalAnchor.getAttribute("data-http-url");
const href = externalAnchor.getAttribute("href");
- const httpHref = href ? LinkUtils.httpUrlHrefOrNull(href.trim()) : null;
+ const httpHref = dataHttp
+ ? LinkUtils.httpUrlHrefOrNull(dataHttp.trim())
+ : href
+ ? LinkUtils.httpUrlHrefOrNull(href.trim())
+ : null;
if (httpHref) {
stopEvent(event);
openExternalHttp(httpHref);
diff --git a/meshchatx/src/frontend/js/nomadFavouritesLayoutStore.js b/meshchatx/src/frontend/js/nomadFavouritesLayoutStore.js
index 8e5e206a..a4a7cac0 100644
--- a/meshchatx/src/frontend/js/nomadFavouritesLayoutStore.js
+++ b/meshchatx/src/frontend/js/nomadFavouritesLayoutStore.js
@@ -166,6 +166,20 @@ export function readLocalNomadFavouritesLayout() {
return null;
}
+export function clearLocalNomadFavouritesLayout() {
+ try {
+ if (typeof window === "undefined" || !window.localStorage) {
+ return;
+ }
+ window.localStorage.removeItem(NOMAD_FAVOURITES_LAYOUT_KEY);
+ window.localStorage.removeItem(NOMAD_FAVOURITES_LEGACY_ORDER_KEY);
+ } catch {
+ // ignore
+ }
+ lastSavedSerialized = "";
+ pendingSaveLayout = null;
+}
+
function writeLocalLayout(layout) {
try {
if (typeof window === "undefined" || !window.localStorage) {
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md b/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
index 441c8f07..1eb409b1 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
@@ -63,7 +63,7 @@ The same backend code powers Docker images, Python wheels, Linux packages, Elect
| Identities | `/identities` | Create, import, or switch identities |
| Documentation | `/documentation` | MeshChatX guides and the Reticulum manual |
-Relay chat appears only when `rrc_enabled` is turned on in settings.
+Relay chat appears only when `rrc_enabled` is turned on in settings. When enabled, the Relay chat icon can show a red mention count. Messages shows unread conversation count. Calls shows unread missed-call count.
## Documentation in the app
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md b/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
index 800edc48..cfe7cff3 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
@@ -36,8 +36,6 @@ Quick start with Compose:
docker compose up -d
```
-For [Coolify](https://coolify.io/docs/knowledge-base/docker/compose), deploy with `docker-compose.coolify.yml`. Assign a domain that includes container port `8000` (for example `https://mesh.example.com:8000`). That file serves HTTP inside the container so Coolify can terminate TLS at the proxy.
-
Manual run with a named volume for persistence:
```bash
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md b/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md
index d54688de..64bd5054 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md
@@ -6,6 +6,8 @@ MeshChatX uses LXMF (LXMF Message Format) for direct and store-and-forward messa
Open **Messages** to see your conversation list. Each row is a peer destination you have exchanged traffic with or selected from announces.
+The Messages sidebar icon shows a red count when you have unread LXMF conversations. Opening a thread marks it read. If a new message arrives while that thread is already open, it is marked read without needing to switch away and back.
+
From a conversation you can:
- Send and receive text messages
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md b/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
index 8026202c..3f2a62b0 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
@@ -17,11 +17,11 @@ Use these when messages or pages fail despite interfaces showing as enabled.
## File transfer and shell
-| Tool | Purpose |
-| ------------ | ---------------------------------------------------- |
-| RNCP | Send or fetch files over Reticulum |
+| Tool | Purpose |
+| ------------ | -------------------------------------------------------- |
+| RNCP | Send or fetch files over Reticulum |
| RNS FileSync | Sync a directory with peers over `rns_filesync.filesync` |
-| RNSH | Remote shell sessions with streamed output |
+| RNSH | Remote shell sessions with streamed output |
RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`.
FileSync progress and peer events use `filesync.sync.progress`, `filesync.peer.connected`, `filesync.peer.disconnected`, `filesync.file.updated`, `filesync.file.deleted`, and `filesync.error`.
diff --git a/tests/backend/test_path_jail_oracles.py b/tests/backend/test_path_jail_oracles.py
new file mode 100644
index 00000000..f0f1c563
--- /dev/null
+++ b/tests/backend/test_path_jail_oracles.py
@@ -0,0 +1,137 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Oracles for path-jail / identity-isolation bugs across storage handlers."""
+
+from __future__ import annotations
+
+import os
+import zipfile
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from meshchatx.src.backend.bot_handler import BotHandler
+from meshchatx.src.backend.database import Database, DatabaseRestoreError
+from meshchatx.src.backend.rncp_handler import RNCPHandler
+from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
+from meshchatx.src.backend.web_audio_bridge import WebAudioBridge
+
+
+def test_rncp_safe_filename_rejects_dotdot():
+ assert RNCPHandler._safe_received_filename(b"..") == "downloaded_file"
+ assert RNCPHandler._safe_received_filename("../secret") == "secret"
+ assert RNCPHandler._safe_received_filename("a/../../etc/passwd") == "passwd"
+ assert RNCPHandler._safe_received_filename("ok.bin") == "ok.bin"
+
+
+def test_rncp_path_under_dir_blocks_escape(tmp_path):
+ handler = RNCPHandler(MagicMock(), MagicMock(), str(tmp_path))
+ recv = tmp_path / "rncp_received"
+ recv.mkdir()
+ safe = handler._path_under_dir(str(recv), "file.bin")
+ assert safe.startswith(str(recv.resolve()))
+ with pytest.raises(PermissionError):
+ handler._path_under_dir(str(recv), "../outside.bin")
+
+
+def test_rncp_fetch_save_dir_jails_outside_storage(tmp_path):
+ storage = tmp_path / "id"
+ storage.mkdir()
+ handler = RNCPHandler(MagicMock(), MagicMock(), str(storage))
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ with pytest.raises(PermissionError):
+ handler._resolve_fetch_save_dir(str(outside))
+ nested = handler._resolve_fetch_save_dir("rncp/custom")
+ assert nested.startswith(str(storage.resolve()))
+
+
+def test_filesync_rejects_identity_root_and_reserved(tmp_path):
+ storage = tmp_path / "id"
+ storage.mkdir()
+ (storage / "identity").mkdir()
+ (storage / "bots").mkdir()
+ handler = RnsFilesyncHandler(MagicMock(), SimpleNamespace(hash=b"\x11" * 16), str(storage))
+ assert handler._resolve_sync_directory(str(storage)) is None
+ assert handler._resolve_sync_directory(str(storage / "identity")) is None
+ assert handler._resolve_sync_directory(str(storage / "bots")) is None
+ ok = handler._resolve_sync_directory(str(storage / "filesync" / "custom"))
+ assert ok is not None
+ assert ok.endswith("filesync/custom") or ok.endswith("filesync\\custom")
+
+
+def test_restore_rejects_bad_backup_without_wiping_live_db(tmp_path):
+ db_path = tmp_path / "main.db"
+ db = Database(str(db_path))
+ db.initialize()
+ assert db_path.is_file()
+ assert Database._looks_like_sqlite(str(db_path))
+
+ bad = tmp_path / "bad.db"
+ bad.write_bytes(b"not a sqlite database")
+
+ with pytest.raises(DatabaseRestoreError, match="not a valid SQLite"):
+ db.restore_database(str(bad))
+
+ assert db_path.is_file()
+ assert Database._looks_like_sqlite(str(db_path))
+ db.close_all()
+
+
+@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
+def test_backup_skips_symlink_files_outside_identity(tmp_path):
+ identity = tmp_path / "identity"
+ identity.mkdir()
+ outside = tmp_path / "OUTSIDE"
+ outside.mkdir()
+ secret = outside / "secret.txt"
+ secret.write_text("leaked", encoding="utf-8")
+ link = identity / "secret.txt"
+ link.symlink_to(secret)
+
+ db_path = identity / "database.db"
+ db = Database(str(db_path))
+ db.initialize()
+ backup = db.backup_database(str(identity))
+ db.close_all()
+
+ with zipfile.ZipFile(backup["path"], "r") as zf:
+ names = zf.namelist()
+ assert "secret.txt" not in names
+
+
+def test_bot_delete_refuses_rmtree_outside_bots_dir(tmp_path):
+ identity = tmp_path / "id"
+ identity.mkdir()
+ outside = tmp_path / "precious"
+ outside.mkdir()
+ marker = outside / "keep.txt"
+ marker.write_text("do not delete", encoding="utf-8")
+
+ handler = BotHandler(str(identity))
+ bot_id = "evilbot"
+ handler.bots_state = [
+ {
+ "id": bot_id,
+ "template_id": "echo",
+ "name": "Evil",
+ "storage_dir": str(outside),
+ "enabled": False,
+ "pid": None,
+ },
+ ]
+ handler.stop_bot = MagicMock()
+ handler._save_state = MagicMock()
+
+ assert handler.delete_bot(bot_id) is True
+ assert marker.is_file()
+ assert marker.read_text(encoding="utf-8") == "do not delete"
+
+
+def test_web_audio_push_drops_without_active_call():
+ tele_mgr = SimpleNamespace(telephone=None, is_voicemail_session_active=False)
+ bridge = WebAudioBridge(tele_mgr, config_manager=None, force_enabled=True)
+ bridge.tx_source = MagicMock()
+ bridge.push_client_frame(b"\x00\x01")
+ bridge.tx_source.push_pcm.assert_not_called()
diff --git a/tests/backend/test_rns_filesync_handler.py b/tests/backend/test_rns_filesync_handler.py
index d3e78e73..2f87bf05 100644
--- a/tests/backend/test_rns_filesync_handler.py
+++ b/tests/backend/test_rns_filesync_handler.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import os
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -36,10 +37,16 @@ def test_default_status_not_running(handler, mock_identity):
assert status["storage_directory"] == handler.storage_dir
+def test_list_directories_missing_sync_path_falls_back(handler):
+ missing = f"{handler.storage_dir}/filesync/sync/does-not-exist-yet"
+ result = handler.list_directories(missing)
+ assert result["ok"] is True
+ assert result["current"].startswith(handler.storage_dir)
+ assert os.path.isdir(result["current"])
+
+
def test_list_directories_defaults_to_filesync_root(handler):
nested = f"{handler.storage_dir}/filesync/photos"
- import os
-
os.makedirs(nested, exist_ok=True)
result = handler.list_directories()
assert result["ok"] is True
diff --git a/tests/backend/test_rns_filesync_security.py b/tests/backend/test_rns_filesync_security.py
index 8de6d4f8..08f742de 100644
--- a/tests/backend/test_rns_filesync_security.py
+++ b/tests/backend/test_rns_filesync_security.py
@@ -4,6 +4,7 @@
from __future__ import annotations
+import os
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -97,7 +98,10 @@ def test_list_directories_rejects_traversal(handler):
if result.get("ok"):
assert result["current"].startswith(handler.storage_dir)
else:
- assert "identity storage" in result["error"] or "not a directory" in result["error"]
+ assert (
+ "identity storage" in result["error"]
+ or "not a directory" in result["error"]
+ )
def test_create_directory_rejects_dotfiles_and_separators(handler):
@@ -182,6 +186,14 @@ def test_sync_directory_cannot_escape_identity_storage(handler, tmp_path):
"/filesync/custom",
)
+ # Identity root and reserved top-level trees must never be sync roots.
+ root_reject = handler.update_settings(sync_directory=handler.storage_dir)
+ assert root_reject["ok"] is False
+ bots = os.path.join(handler.storage_dir, "bots")
+ os.makedirs(bots, exist_ok=True)
+ bots_reject = handler.update_settings(sync_directory=bots)
+ assert bots_reject["ok"] is False
+
def test_start_rejects_escaped_sync_directory(handler, tmp_path):
outside = tmp_path / "escape_me"
diff --git a/tests/frontend/DownloadUtils.test.js b/tests/frontend/DownloadUtils.test.js
index 7ab960a8..0fd81735 100644
--- a/tests/frontend/DownloadUtils.test.js
+++ b/tests/frontend/DownloadUtils.test.js
@@ -64,4 +64,26 @@ describe("DownloadUtils", () => {
expect(append).toHaveBeenCalledWith(link);
expect(remove).toHaveBeenCalledTimes(1);
});
+
+ it("downloadFromApiResponse reads Fetch Headers via .get()", async () => {
+ const saveDownload = vi.fn();
+ window.MeshChatXAndroid = { saveDownload };
+ const headers = new Headers({
+ "content-disposition": 'attachment; filename="from-headers.zip"',
+ "content-type": "application/zip",
+ });
+ await DownloadUtils.downloadFromApiResponse(
+ {
+ data: new Uint8Array([1]),
+ headers,
+ },
+ "fallback.zip"
+ );
+ expect(saveDownload).toHaveBeenCalledWith("from-headers.zip", expect.any(String));
+ });
+
+ it("headerValue works for plain objects and Headers", () => {
+ expect(DownloadUtils.headerValue({ "Content-Type": "text/plain" }, "content-type")).toBe("text/plain");
+ expect(DownloadUtils.headerValue(new Headers({ "content-type": "a/b" }), "Content-Type")).toBe("a/b");
+ });
});
diff --git a/tests/frontend/LinkUtils.test.js b/tests/frontend/LinkUtils.test.js
index 455e0fb2..88c4c62a 100644
--- a/tests/frontend/LinkUtils.test.js
+++ b/tests/frontend/LinkUtils.test.js
@@ -97,34 +97,36 @@ describe("LinkUtils.js", () => {
it("detects http links", () => {
const text = "visit http://example.com";
const result = LinkUtils.renderStandardLinks(text);
- expect(result).toMatch(/<a href="http:\/\/example\.com\/?"/);
+ expect(result).toContain('href="#"');
+ expect(result).toMatch(/data-http-url="http:\/\/example\.com\/?"/);
});
it("detects https links", () => {
const text = "visit https://example.com/path?query=1";
const result = LinkUtils.renderStandardLinks(text);
- expect(result).toContain('<a href="https://example.com/path?query=1"');
+ expect(result).toContain('href="#"');
+ expect(result).toContain('data-http-url="https://example.com/path?query=1"');
});
it("trims trailing punctuation from detected urls", () => {
const result = LinkUtils.renderStandardLinks("visit https://example.com/path?x=1, now");
- expect(result).toContain('href="https://example.com/path?x=1"');
+ expect(result).toContain('data-http-url="https://example.com/path?x=1"');
expect(result).toContain("</a>, now");
});
it("keeps balanced parenthesis in url but trims unmatched trailing one", () => {
const withBalanced = LinkUtils.renderStandardLinks("see https://example.com/path_(v1)");
- expect(withBalanced).toContain('href="https://example.com/path_(v1)"');
+ expect(withBalanced).toContain('data-http-url="https://example.com/path_(v1)"');
const withTrailing = LinkUtils.renderStandardLinks("see (https://example.com/path_(v1))");
- expect(withTrailing).toContain('href="https://example.com/path_(v1)"');
+ expect(withTrailing).toContain('data-http-url="https://example.com/path_(v1)"');
expect(withTrailing).toContain("</a>)");
});
- it("keeps escaped entity query content in href", () => {
+ it("keeps escaped entity query content in data-http-url", () => {
const text = "visit https://example.com/search?q=a&lang=en";
const result = LinkUtils.renderStandardLinks(text);
- expect(result).toContain('href="https://example.com/search?q=a&lang=en"');
+ expect(result).toContain('data-http-url="https://example.com/search?q=a&amp;lang=en"');
});
});
@@ -132,7 +134,7 @@ describe("LinkUtils.js", () => {
it("detects both types of links", () => {
const text = "Check https://google.com and nomadnet://1dfeb0d794963579bd21ac8f153c77a4";
const result = LinkUtils.renderAllLinks(text);
- expect(result).toMatch(/<a href="https:\/\/google\.com\/?"/);
+ expect(result).toMatch(/data-http-url="https:\/\/google\.com\/?"/);
expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/index.mu"');
});
@@ -166,7 +168,7 @@ describe("LinkUtils.js", () => {
expect(result).not.toContain("lxmf-link");
expect(result).not.toContain("nomadnet-link");
expect(result).toContain(
- 'href="https://github.com/org/repo/blob/9a47f3fc51dd3318aec0d2eb9ab6fc497c0f1aef/electron-builder.yml#L29"'
+ 'data-http-url="https://github.com/org/repo/blob/9a47f3fc51dd3318aec0d2eb9ab6fc497c0f1aef/electron-builder.yml#L29"'
);
});
@@ -201,7 +203,7 @@ describe("LinkUtils.js", () => {
it("stops URL at space so no script in same line", () => {
const text = "https://example.com javascript:alert(1)";
const result = LinkUtils.renderStandardLinks(text);
- expect(result).toMatch(/<a href="https:\/\/example\.com\/?"/);
+ expect(result).toMatch(/data-http-url="https:\/\/example\.com\/?"/);
expect(result).not.toMatch(/href="[^"]*javascript:/);
});
diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js
index e6066759..e713a0dc 100644
--- a/tests/frontend/MarkdownRenderer.test.js
+++ b/tests/frontend/MarkdownRenderer.test.js
@@ -59,7 +59,7 @@ describe("MarkdownRenderer.js", () => {
it("keeps underscores intact in long https links", () => {
const url = "https://github.com/Quad4-Software/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md";
const result = MarkdownRenderer.render(`visit ${url}`);
- expect(result).toContain(`href="${url}"`);
+ expect(result).toContain(`data-http-url="${url}"`);
expect(result).toContain(url);
expect(result).not.toContain("<em>on</em>");
expect(result).not.toContain("<em>raspberry</em>");
@@ -90,7 +90,7 @@ describe("MarkdownRenderer.js", () => {
const url =
"https://example.com/docs/meshchatx_on_raspberry_pi.md?file=meshchatx_on_raspberry_pi.md#meshchatx_on_raspberry_pi";
const result = MarkdownRenderer.render(`see ${url} now`);
- expect(result).toContain(`href="${url}"`);
+ expect(result).toContain(`data-http-url="${url}"`);
expect(result).toContain(url);
expect(result).not.toContain("<em>on</em>");
expect(result).not.toContain("<em>raspberry</em>");
@@ -100,34 +100,34 @@ describe("MarkdownRenderer.js", () => {
const a = "https://example.com/docs/meshchatx_on_pi.md";
const b = "https://example.com/plain";
const result = MarkdownRenderer.render(`links: ${a} and ${b}`);
- expect(result).toContain(`href="${a}"`);
- expect(result).toContain(`href="${b}"`);
- expect((result.match(/<a href=/g) || []).length).toBe(2);
+ expect(result).toContain(`data-http-url="${a}"`);
+ expect(result).toContain(`data-http-url="${b}"`);
+ expect((result.match(/data-http-url=/g) || []).length).toBe(2);
});
it("trims trailing punctuation around links while keeping display punctuation", () => {
const result = MarkdownRenderer.render("Check (https://example.com/path_(v1)), and continue.");
- expect(result).toContain('href="https://example.com/path_(v1)"');
+ expect(result).toContain('data-http-url="https://example.com/path_(v1)"');
expect(result).toContain("</a>), and continue.");
});
it("supports encoded chars and balanced parentheses in link path", () => {
const url = "https://example.com/docs/file%5Fname_(v1).md";
const result = MarkdownRenderer.render(`open ${url}`);
- expect(result).toContain(`href="${url}"`);
+ expect(result).toContain(`data-http-url="${url}"`);
});
it("keeps escaped entities in query string links", () => {
const url = "https://example.com/search?q=a&lang=en";
const result = MarkdownRenderer.render(`lookup ${url}`);
- expect(result).toContain('href="https://example.com/search?q=a&amp;lang=en"');
- expect(result).toContain("https://example.com/search?q=a&amp;lang=en");
+ expect(result).toContain('data-http-url="https://example.com/search?q=a&amp;amp;lang=en"');
+ expect(result).toContain("https://example.com/search?q=a&amp;amp;lang=en");
});
it("handles links at line boundaries with newline conversion", () => {
const url = "https://example.com/meshchatx_on_pi.md";
const result = MarkdownRenderer.render(`${url}\nnext line`);
- expect(result).toContain(`href="${url}"`);
+ expect(result).toContain(`data-http-url="${url}"`);
expect(result).toContain("<br>next line");
});
@@ -136,7 +136,7 @@ describe("MarkdownRenderer.js", () => {
const result = MarkdownRenderer.render(`_label_ ${url} _tail_`);
expect(result).toContain("<em>label</em>");
expect(result).toContain("<em>tail</em>");
- expect(result).toContain(`href="${url}"`);
+ expect(result).toContain(`data-http-url="${url}"`);
expect(result).not.toContain("<em>on</em>");
});
@@ -518,7 +518,7 @@ describe("MarkdownRenderer.js", () => {
const result = MarkdownRenderer.render(msg);
expect(result).toContain("<h1");
expect(result).toContain(
- 'href="https://github.com/Quad4-Software/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md"'
+ 'data-http-url="https://github.com/Quad4-Software/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md"'
);
expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/meshchatx_on_pi.mu"');
expect(result).toContain("<code");
@@ -560,7 +560,7 @@ describe("renderBasic (RRC / limited chat markdown)", () => {
const result = MarkdownRenderer.renderBasic("hi <script>alert(1)</script> https://example.com/a");
expect(result).not.toContain("<script>");
expect(result).toContain("<script>");
- expect(result).toContain('href="https://example.com/a"');
+ expect(result).toContain('data-http-url="https://example.com/a"');
});
it("preserves line breaks as br tags", () => {
diff --git a/tests/frontend/NomadRichHtmlLinks.test.js b/tests/frontend/NomadRichHtmlLinks.test.js
index 848050c8..29dfc589 100644
--- a/tests/frontend/NomadRichHtmlLinks.test.js
+++ b/tests/frontend/NomadRichHtmlLinks.test.js
@@ -53,6 +53,15 @@ describe("NomadRichHtmlLinks", () => {
expect(ev.defaultPrevented).toBe(true);
});
+ it("opens data-http-url links even when href is #", () => {
+ holder.innerHTML = '<a href="#" data-http-url="https://example.com/safe">x</a>';
+ const openExternalHttp = vi.fn();
+ const ev = clickEvent(holder.querySelector("a"));
+ expect(handleRichHtmlLinkClick(ev, { openExternalHttp })).toBe(true);
+ expect(openExternalHttp).toHaveBeenCalledWith("https://example.com/safe");
+ expect(ev.defaultPrevented).toBe(true);
+ });
+
it("scrolls fragment anchors within scrollRoot", () => {
holder.innerHTML = '<div id="target">t</div><a href="#target">f</a>';
const target = holder.querySelector("#target");
diff --git a/tests/frontend/RnsFilesyncPage.test.js b/tests/frontend/RnsFilesyncPage.test.js
index 434ddded..6ab997f9 100644
--- a/tests/frontend/RnsFilesyncPage.test.js
+++ b/tests/frontend/RnsFilesyncPage.test.js
@@ -233,9 +233,8 @@ describe("FilesyncDirectoryBrowserModal.vue", () => {
});
it("loads directories when opened and selects a path", async () => {
- const { default: FilesyncDirectoryBrowserModal } = await import(
- "@/components/filesync/FilesyncDirectoryBrowserModal.vue"
- );
+ const { default: FilesyncDirectoryBrowserModal } =
+ await import("@/components/filesync/FilesyncDirectoryBrowserModal.vue");
const wrapper = mount(FilesyncDirectoryBrowserModal, {
props: {
open: true,
@@ -252,9 +251,7 @@ describe("FilesyncDirectoryBrowserModal.vue", () => {
},
});
await vi.waitFor(() =>
- expect(apiMock.get).toHaveBeenCalledWith(
- expect.stringContaining("/api/v1/filesync/directories")
- )
+ expect(apiMock.get).toHaveBeenCalledWith(expect.stringContaining("/api/v1/filesync/directories"))
);
expect(wrapper.vm.directories).toHaveLength(1);
await wrapper.vm.confirmSelection();
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────